CHART_COLORS = [
'#020A3E', // Primary Navy
'#0D83FF', // Secondary Blue
'#47B5FF', // Light Blue
'#94A3B8', // Slate 400
'#CBD5E1', // Slate 300
'#F1F5F9' // Slate 100
];
// 1. LOAD DATA & HYDRATE STRINGS
lookup_raw = FileAttachment("string_lookup.csv").csv({typed: true})
lookup = new Map(lookup_raw.map(d => [d.id, d.text]))
data_raw = FileAttachment("inman_survey_cube_final.csv").csv({typed: true})
data = data_raw.map(d => {
const dt = new Date(d.Date);
dt.setUTCDate(20);
return {
...d,
Date: dt,
Region: lookup.get(d.Region),
Brokerage: lookup.get(d.Brokerage),
Volume: lookup.get(d.Volume),
Question: lookup.get(d.Question),
Response: lookup.get(d.Response),
Track: lookup.get(d.Track)
};
})
// 2. STATE
mutable selectedQuestion = null
// 3. TRACK SELECTOR
viewof selectedTrack = {
const container = html`<div class="track-toggle">
<button class="track-btn active" value="Agents">AGENTS</button>
<button class="track-btn" value="Brokerage Leaders">LEADERS</button>
</div>`;
const buttons = container.querySelectorAll("button");
buttons.forEach(btn => {
btn.onclick = (e) => {
buttons.forEach(b => { b.classList.remove("active"); });
btn.classList.add("active");
container.value = btn.value;
container.dispatchEvent(new CustomEvent("input"));
};
});
container.value = "Agents";
return container;
}
// Reset filters when track changes
{
selectedTrack;
const modeButtons = document.querySelectorAll("#grouping-mode-container .segment-btn");
if (modeButtons.length > 0) {
modeButtons[0].click(); // Reset to "Total"
}
}
viewof groupingMode = {
const options = selectedTrack === "Agents" ? ["Total", "Region", "Brokerage", "Volume"] : ["Total", "Region", "Brokerage"];
const container = html`<div class="segmented-control ml-2">
${options.map(opt => html`<button class="segment-btn ${opt === "Total" ? 'active' : ''}" value="${opt}">${opt}</button>`)}
</div>`;
const buttons = container.querySelectorAll("button");
buttons.forEach(btn => {
btn.onclick = () => {
buttons.forEach(b => b.classList.remove("active"));
btn.classList.add("active");
container.value = btn.value;
container.dispatchEvent(new CustomEvent("input"));
};
});
container.value = "Total";
return container;
}
viewof snapshotSubgroups = {
const mode = groupingMode;
const container = html`<div class="flex flex-wrap gap-x-5 gap-y-2"></div>`;
if (mode === "Total" || !mode) {
container.value = [];
return container;
}
// Get unique subgroups for the active dimension
let groups = [];
if (mode === "Region") groups = ["West", "Midwest", "South", "Northeast"];
else if (mode === "Brokerage") groups = ["Franchise", "Independent brokerage, privately held", "Independent brokerage, publicly traded"];
else if (mode === "Volume") groups = ["High Volume", "Lower Volume"];
const selected = new Set(groups);
const render = () => {
container.innerHTML = "";
groups.forEach((g, i) => {
const isSelected = selected.has(g);
const color = CHART_COLORS[i % CHART_COLORS.length];
const btn = html`
<div class="flex items-center cursor-pointer transition-all hover:translate-y-[-1px] ${isSelected ? 'opacity-100' : 'opacity-25'}" style="gap: 8px;">
<div style="width: 14px; height: 14px; border-radius: 3px; background-color: ${color}; box-shadow: 0 1px 2px rgba(0,0,0,0.1)"></div>
<span class="text-[0.8rem] font-medium text-slate-600 tracking-tight select-none">${g}</span>
</div>`;
btn.onclick = () => {
if (selected.has(g)) {
if (selected.size > 1) selected.delete(g);
} else {
selected.add(g);
}
render();
container.value = Array.from(selected);
container.dispatchEvent(new CustomEvent("input"));
};
container.append(btn);
});
};
render();
container.value = Array.from(selected);
return container;
}
uniqueDates = Array.from(new Set(data.map(d => d.Date.getTime())))
.sort()
.reverse()
.map(ts => new Date(ts))
viewof dateFilter = {
const CalendarIcon = () => html`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>`;
const form = html`
<div class="flex items-center space-x-3 bg-white/10 px-3 py-1.5 rounded border border-white/20">
<span class="text-blue-200">${CalendarIcon()}</span>
<select class="bg-transparent text-white font-medium text-sm focus:outline-none cursor-pointer">
${uniqueDates.map(d => html`
<option value="${d.getTime()}" class="text-slate-800">
${d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })}
</option>
`)}
</select>
</div>
`;
const select = form.querySelector("select");
form.value = uniqueDates[0];
select.onchange = () => {
form.value = new Date(parseInt(select.value));
form.dispatchEvent(new CustomEvent("input"));
};
return form;
}
// 5. DOM INJECTION
{
const inject = () => {
const monthEl = document.getElementById('month-selector-header');
const trackEl = document.getElementById('track-toggle-container');
const modeEl = document.getElementById('grouping-mode-container');
const legendEl = document.getElementById('snapshot-legend-container');
const legendWrapper = document.getElementById('snapshot-legend-wrapper');
if (monthEl) { monthEl.innerHTML = ""; monthEl.append(viewof dateFilter); }
if (trackEl) { trackEl.innerHTML = ""; trackEl.append(viewof selectedTrack); }
if (modeEl) { modeEl.innerHTML = ""; modeEl.append(viewof groupingMode); }
if (legendEl && groupingMode !== "Total") {
legendEl.innerHTML = "";
legendEl.append(viewof snapshotSubgroups);
if (legendWrapper) legendWrapper.style.display = "block";
} else if (legendWrapper) {
legendWrapper.style.display = "none";
}
};
inject();
setTimeout(inject, 100);
}
questionDataSnapshot = snapshotDataFiltered.filter(d => d.Question === selectedQuestion)
snapshotData = questionDataSnapshot.map(d => ({
...d,
Percentage: d.Total_N > 0 ? d.Count / d.Total_N : 0
}))
questionDataTrend = trendDataFixed.filter(d => d.Question === selectedQuestion)
chartDataTrend = questionDataTrend.map(d => ({
...d,
Percentage: d.Total_N > 0 ? d.Count / d.Total_N : 0
})).sort((a, b) => a.Date - b.Date)
// 11. CHARTS
// Helper to get available width in the main area (subtract padding)
mainWidth = {
const isMobile = width < 768;
const sidebarW = isMobile ? 0 : 340;
const mainPadding = isMobile ? 48 : 64; // p-6 (48px) vs p-8 (64px)
const cardPadding = 48; // p-6 on cards
return width - sidebarW - mainPadding - cardPadding;
}
wrap = (text, width = 35) => {
if (text === null || text === undefined) return "";
const str = String(text);
const words = str.split(/\s+/);
let line = [], length = 0, lines = [];
for (const word of words) {
if (length + word.length > width) { lines.push(line.join(" ")); line = []; length = 0; }
line.push(word); length += word.length + 1;
}
if (line.length) lines.push(line.join(" "));
return lines.join("\n");
}
// Interactive Trend Legend Selection (Remains dynamic for FIXED trend data)
allResponses = Array.from(new Set(chartDataTrend.map(d => d.Response)))
viewof trendSelection = {
// Reset selection when the question changes
selectedQuestion;
const container = html`<div class="flex flex-wrap gap-x-5 gap-y-2"></div>`;
const selected = new Set(allResponses);
const render = () => {
container.innerHTML = "";
allResponses.forEach((resp, i) => {
const isSelected = selected.has(resp);
const color = CHART_COLORS[i % CHART_COLORS.length];
const btn = html`
<div class="flex items-center cursor-pointer transition-all hover:translate-y-[-1px] ${isSelected ? 'opacity-100' : 'opacity-25'}" style="gap: 8px;">
<div style="width: 14px; height: 14px; border-radius: 3px; background-color: ${color}; box-shadow: 0 1px 2px rgba(0,0,0,0.1)"></div>
<span class="text-[0.8rem] font-medium text-slate-600 tracking-tight select-none">${resp}</span>
</div>`;
btn.onclick = () => {
if (selected.has(resp)) {
if (selected.size > 1) selected.delete(resp);
} else {
selected.add(resp);
}
render();
container.value = Array.from(selected);
container.dispatchEvent(new CustomEvent("input"));
};
container.append(btn);
});
};
render();
container.value = Array.from(selected);
return container;
}
trendDataFiltered = chartDataTrend.filter(d => trendSelection.includes(d.Response))
snapshotPlot = {
const isMobile = width < 768;
const labelWrap = isMobile ? 25 : 35;
const mode = groupingMode;
const isComparison = mode !== "Total";
const marginL = isMobile ? 120 : 160;
const currentSnapshot = snapshotData.filter(d =>
d.Date.toISOString().split('T')[0] === (dateFilter ? dateFilter.toISOString().split('T')[0] : "")
);
if (currentSnapshot.length === 0) return html`<div class="flex items-center justify-center h-[400px] text-slate-400 font-medium italic">No data available for this selection.</div>`;
if (isComparison) {
// Grouped Bar Chart for Comparison
return Plot.plot({
width: mainWidth,
marginLeft: marginL,
height: Math.max(500, d3.sum(currentSnapshot, d => wrap(d.Response, labelWrap).split("\n").length) * 12 + (currentSnapshot.length * 8)),
color: { range: CHART_COLORS },
x: { label: "Percentage", tickFormat: "%", domain: [0, 1], grid: true },
y: {
label: null,
axis: null,
sort: {y: "x", reverse: true}
},
fy: {
label: null,
axis: "left",
domain: Array.from(new Set(currentSnapshot.map(d => d.Response))),
tickFormat: d => wrap(d, labelWrap)
},
marks: [
Plot.barX(currentSnapshot, {
x: "Percentage",
y: mode,
fill: mode,
fy: "Response",
title: d => `${d[mode]}\n${(d.Percentage * 100).toFixed(1)}% (N=${d.Total_N})`,
tip: true
})
]
});
}
// Standard Bar Chart for Single Selection (Total)
return Plot.plot({
width: mainWidth,
marginLeft: marginL,
height: Math.max(400, d3.sum(currentSnapshot, d => wrap(d.Response, labelWrap).split("\n").length) * 14 + (currentSnapshot.length * 10)),
color: {range: CHART_COLORS, domain: allResponses},
x: {label: "Percentage", tickFormat: "%", domain: [0, 1], grid: true},
y: {
label: null,
domain: currentSnapshot.sort((a,b) => b.Percentage - a.Percentage).map(d => d.Response),
tickFormat: d => wrap(d, labelWrap),
padding: 0.2
},
marks: [
Plot.barX(currentSnapshot, {
x: "Percentage",
y: "Response",
fill: "Response",
tip: true,
title: d => `${d.Response}\n${(d.Percentage * 100).toFixed(1)}%`
}),
Plot.text(currentSnapshot, {
x: "Percentage",
y: "Response",
text: d => `${(d.Percentage * 100).toFixed(0)}%`,
dx: 5,
textAnchor: "start",
fontWeight: "bold",
fill: "#020A3E"
})
]
})
}
trendPlot = Plot.plot({
width: mainWidth,
marginLeft: 50, height: 400,
color: {range: CHART_COLORS, domain: allResponses},
x: {
label: null,
ticks: width < 600 ? 5 : "month",
tickFormat: d => d.toLocaleDateString('en-US', {month: 'short', year: width < 600 ? '2-digit' : 'numeric', timeZone: 'UTC'})
},
y: {label: "Share", tickFormat: "%", grid: true},
marks: [
Plot.areaY(trendDataFiltered, {
x: "Date",
y: "Percentage",
fill: "Response",
order: "sum",
curve: "monotone-x",
tip: true,
title: d => `${d.Date.toLocaleDateString('en-US', {day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC'})}\n${d.Response}: ${(d.Percentage * 100).toFixed(1)}%`
}),
Plot.ruleY([0])
]
})
// 12. RENDERER: SNAPSHOT
{
const sCont = document.getElementById('snapshot-chart-container');
if (sCont) {
sCont.innerHTML = "";
try {
sCont.append(snapshotPlot);
} catch (e) {
console.error("Snapshot Plot Error:", e);
sCont.innerHTML = `<div class="p-4 text-red-500 text-xs">Error rendering chart: ${e.message}</div>`;
}
}
}